SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
15.7 KB · 326 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { ArrowRight, Brain, Eye, Link2, MessagesSquare, Scissors, Wrench } from "lucide-react";5import { getPublicShare, readShareMeta } from "@/lib/conversations/service";6import { LogoMark, Wordmark } from "@/components/brand/logo";7import { ProviderIcon } from "@/components/brand/provider-icon";8import { Badge } from "@/components/ui/badge";9import { Button } from "@/components/ui/button";10import { SimpleMarkdown } from "@/components/markdown/simple-markdown";11import { providerName } from "@/lib/client/providers";12import { formatMs, formatTokens, cn } from "@/lib/utils";1314export const dynamic = "force-dynamic";1516/* ------------------------------------------------------------------------------------------------17 * Snapshot shape (frozen copy written by `shareConversation`). Parsed defensively; the optional first18 * `$meta` element (v2) is skipped by the message parser and read separately with `readShareMeta`.19 * ---------------------------------------------------------------------------------------------- */20type SnapPart =21  | { type: "text"; text: string }22  | { type: "reasoning"; text: string; durationMs?: number }23  | { type: "tool-call"; id?: string; name: string; arguments?: Record<string, unknown>; argumentsText?: string; result?: unknown; isError?: boolean }24  | { type: "citation"; url?: string; title?: string; snippet?: string };2526interface SnapMessage {27  role: "user" | "assistant" | "system";28  content: string;29  modelKey: string | null;30  createdAt: string | null;31  parts: SnapPart[];32  usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | null;33  latencyMs: number | null;34}3536function isRecord(v: unknown): v is Record<string, unknown> {37  return typeof v === "object" && v !== null;38}3940function parseSnapshot(raw: unknown): SnapMessage[] {41  if (!Array.isArray(raw)) return [];42  const out: SnapMessage[] = [];43  for (const m of raw) {44    if (!isRecord(m)) continue;45    const role = m.role === "user" || m.role === "assistant" || m.role === "system" ? m.role : null;46    if (!role) continue;47    const parts: SnapPart[] = [];48    if (Array.isArray(m.parts)) {49      for (const p of m.parts) {50        if (!isRecord(p) || typeof p.type !== "string") continue;51        if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string") parts.push(p as SnapPart);52        else if (p.type === "tool-call" && typeof p.name === "string") parts.push(p as SnapPart);53        else if (p.type === "citation") parts.push(p as SnapPart);54      }55    }56    const createdAt = typeof m.createdAt === "string" ? m.createdAt : m.createdAt instanceof Date ? m.createdAt.toISOString() : null;57    out.push({58      role,59      content: typeof m.content === "string" ? m.content : "",60      modelKey: typeof m.modelKey === "string" ? m.modelKey : null,61      createdAt,62      parts,63      usage: isRecord(m.usage) ? (m.usage as SnapMessage["usage"]) : null,64      latencyMs: typeof m.latencyMs === "number" ? m.latencyMs : null,65    });66  }67  return out;68}6970function splitModelKey(key: string | null): { provider: string | null; model: string | null } {71  if (!key) return { provider: null, model: null };72  const [provider, ...rest] = key.split("/");73  return { provider: provider || null, model: rest.join("/") || key };74}7576function formatDate(d: Date) {77  return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });78}7980/* ------------------------------------------------------------------------------------------------81 * Metadata — noindex, per-share OG title82 * ---------------------------------------------------------------------------------------------- */83export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {84  const { id } = await params;85  const share = await getPublicShareSafe(id, { peek: true });86  const count = share ? parseSnapshot(share.snapshot).filter((m) => m.role !== "system").length : 0;87  const description = share ? `A conversation shared from PolyLLM · ${count} message${count === 1 ? "" : "s"}.` : "This shared conversation is unavailable.";88  return {89    title: share ? share.title : "Shared conversation",90    description,91    robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },92    openGraph: share ? { title: `${share.title} · Shared from PolyLLM`, description, type: "article", siteName: "PolyLLM" } : undefined,93    twitter: share ? { card: "summary", title: `${share.title} · Shared from PolyLLM`, description } : undefined,94  };95}9697async function getPublicShareSafe(id: string, opts: { peek?: boolean } = {}) {98  if (!id || id.length > 128 || !/^[\w-]+$/.test(id)) return null;99  try {100    return await getPublicShare(id, opts);101  } catch {102    return null;103  }104}105106/* ------------------------------------------------------------------------------------------------107 * Page — mobile-first: 16px gutters, stacked header, full-width CTA; widens at sm/md.108 * ---------------------------------------------------------------------------------------------- */109export default async function SharePage({ params }: { params: Promise<{ id: string }> }) {110  const { id } = await params;111  const share = await getPublicShareSafe(id);112  if (!share) notFound();113114  const messages = parseSnapshot(share.snapshot).filter((m) => m.role !== "system");115  const meta = readShareMeta(share.snapshot);116  const models = Array.from(new Set(messages.map((m) => m.modelKey).filter((k): k is string => !!k)));117  const created = share.createdAt instanceof Date ? share.createdAt : new Date(share.createdAt as unknown as string);118  const views = share.viewCount + 1; // this visit was counted after the row was read119120  return (121    <div className="flex min-h-dvh flex-col bg-bg">122      <header className="glass sticky top-0 z-40 pt-[var(--sat)] hairline-b">123        <div className="mx-auto flex h-12 w-full max-w-3xl items-center justify-between gap-3 px-4 sm:h-14 sm:px-6">124          <Link href="/" className="flex min-w-0 items-center gap-2 rounded-md" aria-label="PolyLLM home">125            <LogoMark size={24} />126            <Wordmark className="text-[16px]" />127            <span className="hidden truncate text-[12.5px] text-fg-subtle sm:inline">· Shared conversation</span>128          </Link>129          <Button asChild size="sm" className="shrink-0">130            <Link href="/signup">131              Open PolyLLM132              <ArrowRight />133            </Link>134          </Button>135        </div>136      </header>137138      <main className="mx-auto w-full max-w-3xl flex-1 px-4 pb-14 pt-6 sm:px-6 sm:pt-10">139        <header className="pb-5 hairline-b sm:pb-6">140          <p className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.14em] text-fg-subtle">141            <MessagesSquare className="size-3.5" aria-hidden />142            Shared from PolyLLM143            {meta?.partial ? (144              <Badge variant="accent" className="ml-1 normal-case tracking-normal">145                <Scissors />146                Excerpt · {meta.selectedCount} of {meta.totalCount}147              </Badge>148            ) : null}149          </p>150          <h1 className="mt-2 text-balance text-[24px] font-semibold leading-tight tracking-tight sm:text-3xl">{share.title}</h1>151          <dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[13px] text-fg-muted">152            <div className="flex items-center gap-1.5">153              <dt className="sr-only">Shared on</dt>154              <dd>155                <time dateTime={created.toISOString()}>{formatDate(created)}</time>156              </dd>157            </div>158            <span aria-hidden>·</span>159            <div>160              <dt className="sr-only">Messages</dt>161              <dd>162                {messages.length} message{messages.length === 1 ? "" : "s"}163              </dd>164            </div>165            <span aria-hidden>·</span>166            <div className="flex items-center gap-1 tabular-nums">167              <Eye className="size-3.5 text-fg-subtle" aria-hidden />168              <dt className="sr-only">Views</dt>169              <dd>170                {views} view{views === 1 ? "" : "s"}171              </dd>172            </div>173          </dl>174          {models.length ? (175            <ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Models used">176              {models.map((k) => {177                const { provider, model } = splitModelKey(k);178                return (179                  <li key={k}>180                    <Badge className="h-7 px-2.5 text-[12px]">181                      <ProviderIcon provider={provider} size={13} />182                      {model}183                    </Badge>184                  </li>185                );186              })}187            </ul>188          ) : null}189        </header>190191        <ol className="mt-6 space-y-6 sm:mt-8 sm:space-y-7">192          {messages.map((m, i) => (193            <li key={i} id={`m-${i + 1}`}>194              <MessageView message={m} />195            </li>196          ))}197        </ol>198199        <aside className="panel mt-12 p-5 text-center sm:mt-14 sm:p-8">200          <div className="mx-auto flex size-10 items-center justify-center">201            <LogoMark size={36} />202          </div>203          <p className="mt-3 text-balance text-[17px] font-semibold tracking-tight sm:text-lg">Continue this conversation with your own keys</p>204          <p className="mx-auto mt-2 max-w-md text-balance text-[14px] leading-6 text-fg-muted">One interface, every model. Bring your OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter or Cerebras key — chat, compare in the Arena and track every token.</p>205          <div className="mt-5 flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center">206            <Button asChild size="lg" className="w-full sm:w-auto">207              <Link href="/signup">208                Open PolyLLM209                <ArrowRight />210              </Link>211            </Button>212            <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">213              <Link href="/">Learn more</Link>214            </Button>215          </div>216        </aside>217      </main>218219      <footer className="px-4 pb-[max(20px,var(--sab))] pt-6 text-center text-xs text-fg-subtle hairline-t">220        <p className="text-balance">221          Frozen snapshot shared by a PolyLLM user · attachments are not included · model output can be wrong.{" "}222          <Link href="/privacy" className="underline-offset-4 hover:text-fg hover:underline">223            Privacy224          </Link>225          {" · "}226          <Link href="/terms" className="underline-offset-4 hover:text-fg hover:underline">227            Terms228          </Link>229        </p>230      </footer>231    </div>232  );233}234235/* ------------------------------------------------------------------------------------------------236 * Message rendering237 * ---------------------------------------------------------------------------------------------- */238function MessageView({ message }: { message: SnapMessage }) {239  if (message.role === "user") {240    const text = message.parts.filter((p): p is Extract<SnapPart, { type: "text" }> => p.type === "text").map((p) => p.text).join("\n\n") || message.content;241    return (242      <div className="flex justify-end">243        <div className="max-w-[92%] rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-7 sm:max-w-[80%]">244          <p className="sr-only">You said:</p>245          <SimpleMarkdown>{text}</SimpleMarkdown>246        </div>247      </div>248    );249  }250251  const { provider, model } = splitModelKey(message.modelKey);252  const reasoning = message.parts.filter((p): p is Extract<SnapPart, { type: "reasoning" }> => p.type === "reasoning" && p.text.trim().length > 0);253  const tools = message.parts.filter((p): p is Extract<SnapPart, { type: "tool-call" }> => p.type === "tool-call");254  const citations = message.parts.filter((p): p is Extract<SnapPart, { type: "citation" }> => p.type === "citation" && !!p.url);255  const textParts = message.parts.filter((p): p is Extract<SnapPart, { type: "text" }> => p.type === "text");256  const text = textParts.map((p) => p.text).join("\n\n") || message.content;257  const totalTokens = message.usage?.totalTokens ?? ((message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0) || null);258259  return (260    <article className="flex gap-2.5 sm:gap-3">261      <span className="mt-1 flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-bg-elevated" aria-hidden>262        <ProviderIcon provider={provider} size={14} />263      </span>264      <div className="min-w-0 flex-1">265        <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[13px]">266          <span className="font-medium">{model ?? "Assistant"}</span>267          {provider ? <span className="text-fg-subtle">{providerName(provider)}</span> : null}268        </div>269270        {reasoning.length ? (271          <details className="group mt-2 rounded-lg border border-border bg-bg-subtle/60">272            <summary className="flex min-h-[40px] cursor-pointer select-none items-center gap-2 px-3 py-2 text-[13px] text-fg-muted marker:content-none [&::-webkit-details-marker]:hidden">273              <Brain className="size-3.5" aria-hidden />274              Reasoning275              {reasoning[0].durationMs ? <span className="font-mono text-[11px] text-fg-subtle">· {formatMs(reasoning[0].durationMs)}</span> : null}276              <span className="ml-auto text-[11px] text-fg-subtle group-open:hidden">Show</span>277              <span className="ml-auto hidden text-[11px] text-fg-subtle group-open:inline">Hide</span>278            </summary>279            <div className="border-t border-border px-3 py-3 text-[13px] leading-6 text-fg-muted">280              <SimpleMarkdown className="text-[13px]">{reasoning.map((r) => r.text).join("\n\n")}</SimpleMarkdown>281            </div>282          </details>283        ) : null}284285        {tools.length ? (286          <ul className="mt-2 space-y-1.5">287            {tools.map((t, i) => (288              <li key={t.id ?? i} className="rounded-lg border border-border bg-bg-subtle/60 px-3 py-2">289                <div className="flex items-center gap-2 text-[12.5px]">290                  <Wrench className="size-3.5 text-fg-subtle" aria-hidden />291                  <span className="font-mono font-medium">{t.name}</span>292                  {t.isError ? <Badge variant="danger">error</Badge> : null}293                </div>294                {t.argumentsText || t.arguments ? <pre className="mt-1.5 overflow-x-auto font-mono text-[11.5px] leading-5 text-fg-muted scrollbar-thin">{t.argumentsText ?? JSON.stringify(t.arguments, null, 2)}</pre> : null}295              </li>296            ))}297          </ul>298        ) : null}299300        <div className={cn("mt-2", !text && "text-[14px] italic text-fg-subtle")}>{text ? <SimpleMarkdown>{text}</SimpleMarkdown> : "No text in this message."}</div>301302        {citations.length ? (303          <ol className="mt-3 space-y-1">304            {citations.map((c, i) => (305              <li key={`${c.url}-${i}`} className="flex items-start gap-2 text-[12.5px]">306                <Link2 className="mt-1 size-3.5 shrink-0 text-fg-subtle" aria-hidden />307                <a href={c.url} target="_blank" rel="noopener noreferrer nofollow" className="min-w-0 truncate text-accent underline-offset-4 hover:underline">308                  {c.title || c.url}309                </a>310              </li>311            ))}312          </ol>313        ) : null}314315        {totalTokens || message.latencyMs ? (316          <p className="mt-2.5 font-mono text-[11px] tabular-nums text-fg-subtle">317            {totalTokens ? `${formatTokens(totalTokens)} tokens` : null}318            {totalTokens && message.latencyMs ? " · " : null}319            {message.latencyMs ? formatMs(message.latencyMs) : null}320          </p>321        ) : null}322      </div>323    </article>324  );325}326